Skip to content

feat: derive the version from each record's declared impact#125

Merged
REPPL merged 3 commits into
mainfrom
feat/itd-73-p1-impact-derivation
Jul 22, 2026
Merged

feat: derive the version from each record's declared impact#125
REPPL merged 3 commits into
mainfrom
feat/itd-73-p1-impact-derivation

Conversation

@REPPL

@REPPL REPPL commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Phase 1 of the derived-versioning + auto-changelog + plugin-distribution programme
(plan: .abcd/development/plans/2026-07-21-derived-version-and-changelog.md; spec: spc-10).

Second of five stacked PRs. Based on feat/itd-73-p0-plan-intents (#123) — GitHub
retargets it to main once #123 merges.

It works end to end on this repo

$ go test ./internal/core/changelog/ -run TestDeriveOnThisRepo -v
    derive_test.go:253: base=v0.3.0 bump=breaking next=v0.4.0 added=17 removed=0

Base read from the newest git tag, 17 records in the cut (exactly the set resolved since
v0.3.0), and the bump decided by iss-51.

What lands

The impact field — frontmatter on intents and issues, enum
additive|breaking|fix|internal, explicit, with no default. The plan's outcome 8 is
load-bearing here: the earlier "issues default to fix" rule under-bumped genuinely
feature-adding issues (iss-57 added a --source value, iss-47 added the generated CLI
reference — both filed under ### Added), and the surface guardrail only ever backstops
breaks, never additive under-bumps. internal is excluded from the changelog and drives
no bump; intents are never internal.

Two blocker lints, wired into the real record-lint run via .abcd/record-lint.json:
intent_impact_valid and issue_impact_valid. Each existing tree scanner was split into a
scan half and a validate half so the new rules share one traversal rather than adding a
third walk of the same trees.

The derivationDeriveNext over launch.Semver, including the pre-1.0 row where
breaking bumps the minor. Nothing can derive 1.0.0. The base is the newest git
tag
, not the CHANGELOG heading, because auto-release.yml tags after the ship PR merges
and in that window the two describe different releases; a heading ahead of the tag refuses as
"release in flight".

The cut set is a tree diff of end states (git ls-tree at the tag vs at HEAD), not a
log walk of moves — that is what makes it immune to squash and rebase merges.

The STOP condition — not hit

Phase 1's STOP is "the shipped-since-tag query is not deterministic across a rebase or
squash-merge". TestShippedSinceAgreesAcrossMergeAndSquash builds both histories and passes
under -race. The other pinned caveats are tested too: a within-shipped/ slug rename reads
as delete+add, an intent moved out of shipped/ lands in the removed set, and the
heading-ahead-of-tag case refuses.

Two real bugs the independent review caught, both fixed

  1. Derive refused a whole release over a record it was impossible to fix. A record
    renamed or superseded since the anchor tag is read from the tag's tree, which predates
    this back-fill — so Derive demanded an impact on a blob in immutable history while the
    HEAD copy was already correct. Every release would have been blocked until the rename was
    reverted. Refusal is now scoped to the added side; removed records travel through so
    the ship verb can still emit their Removed line, which is what the plan requires for a
    supersession. Reproduced on a real clone before and after.
  2. abcd capture silently dropped every resolved issue. internal/core/capture enforces
    additionalProperties: false through an allow-list that did not know about impact, so
    all 57 back-filled records failed to parse: abcd capture reported resolved 0 and
    capture list --resolved skipped everything. Now resolved 57. This is exactly the kind
    of silent boundary failure that only shows up when the record and the reader are changed
    by different hands.

The back-fill — this is the part that wants your eye

All 5 shipped intents (all additive) and all 57 resolved issues carry an impact.
It covers all 57 rather than only the 17 in the current cut because the lint gates the whole
resolved/ bucket. Distribution: 4 additive (iss-47, iss-57, iss-74, iss-86), 1
breaking
(iss-51), 22 fix, 30 internal.

Values were proposed from each record's own content and cross-checked against the changelog
section the work was actually filed under, then spot-checked by a second agent. They are
agent-proposed; this review is the confirmation gate.
Worth a look:

  • iss-51 is the single record deciding v0.4.0. Labelled breaking because the strict
    banned_tokens schema is rejected at load, so a previously-valid config stops working.
    If you read that as fix, the derived version becomes v0.3.1 instead.
  • itd-46 was checked specifically for a break and ruled additive: intent new
    survives as a deprecation-warning alias with byte-identical stdout.
  • Three internal calls the proposer flagged as arguable: iss-77 (relocated three
    user-facing command files, but no changelog line), iss-73 (moved a shipped verb's
    report output to a new location), iss-36 (banlist arming plus a corpus drain).

Verification

gofmt -l . silent · go vet ./... · go build ./... · go test ./... · go test -race
on changelog/lint/capture · go run ./cmd/record-lint exit 0 (16 pre-existing WARNs, zero
blockers) · go run ./cmd/abcd --help.

CHANGELOG.md, go.mod, go.sum and both .github/workflows/*.yml are untouched — the
release contract (ADR-37) is preserved, not modified, and no phase in this stack edits the
changelog by hand.

Follow-ups deliberately not in this PR

  • abcd capture resolve cannot set impact, so the tool's own path produces a record the
    new blocker rejects. Same gap in seedDraft on the intent side. Needs a design call on the
    flag surface — worth a captured issue.
  • internal/core/changelog/shipped.go adds a third record-filename matcher alongside
    lint.intentFileRe and lint.issueFileRe; flagged in a source comment for consolidation.
  • frontmatter.Fields does not strip quotes, so a hand-authored impact: "fix" would fail
    while the neighbouring severity: "major" is quoted throughout the ledger. Nothing breaks
    today (the back-fill wrote unquoted values); the first hand-authored quoted value would.

Phase 1 of the derived-versioning programme (itd-73 / spc-10). The version stops
being a number a human picks and becomes a fact computed from what shipped.

THE FIELD
`impact` is new frontmatter on intents and issues, enum additive|breaking|fix|
internal, set explicitly — there is no default. The earlier "issues default to
fix" rule was wrong: it under-bumped genuinely feature-adding issues (iss-57 added
a --source value, iss-47 added the generated CLI reference; both were filed under
"### Added"), and the surface guardrail only ever backstops breaks, never additive
under-bumps. `internal` is excluded from the changelog and drives no bump. Intents
are never `internal` — a press-release-first intent is user-facing by definition.

THE GATES
Two blocker lints, wired into the real record-lint run via .abcd/record-lint.json:
intent_impact_valid (no intent sits in shipped/ without a valid non-internal
impact) and issue_impact_valid (no issue sits in resolved/ without a valid one).
Both reuse the existing tree scanners — each was split into a scan half and a
validate half, so the new rules share one traversal instead of adding a third.

THE DERIVATION
DeriveNext is a pure function over launch.Semver, including the pre-1.0 row where
breaking bumps the MINOR. Nothing can derive 1.0.0; the first major is an explicit
override. The base version is the newest git TAG, not the CHANGELOG heading —
auto-release.yml creates the tag after the ship PR merges, so in that window the
heading and the tag describe different releases. A heading ahead of the tag is a
release in flight and refuses rather than deriving against a mismatched base.

The cut set is a tree diff of end states (git ls-tree at the tag vs at HEAD), not
a log walk of moves, which is what makes it immune to squash and rebase merges —
the phase's declared STOP condition, pinned by a test that runs both histories.
Removals travel through the cut so a supersession surfaces as a Removed line.

THE BACK-FILL
All 5 shipped intents and all 57 resolved issues carry an impact. The lint gates
the whole resolved/ bucket, so the back-fill covers it rather than only the 17
records in the current cut. Values are agent-proposed from each record's own
content and cross-checked against the changelog section the work was filed under;
the maintainer confirms them in review.

Assisted-by: Claude:claude-opus-4-8
Base automatically changed from feat/itd-73-p0-plan-intents to main July 21, 2026 18:35
CI's `check` job checks out at the default depth with `fetch-tags: false`, so
the release tags are simply absent there. TestDeriveOnThisRepo asserted "this
repo has release tags; the anchor must resolve one" and failed the job on both
runners — an assertion about the checkout, not about the code.

Derive's behaviour is correct and unchanged: with no tag it refuses fail-closed
("no release tag found — a cut needs an immutable base"), which is exactly what
a cut attempted from a tagless clone should do. That refusal is pinned
deterministically by TestDeriveRefusalKind over a fixture repo, so skipping the
opportunistic real-repo test loses no coverage of the behaviour.

Reproduced against a `git clone --no-tags` of this branch: the test failed with
CI's exact message before the change and skips after it, with the full package
green in that clone. In a checkout that does carry the tags it still runs and
reports base=v0.3.0 bump=breaking next=v0.4.0 added=17.

Assisted-by: Claude:claude-opus-4-8
Union-merge of the append-only DECISIONS.md log (both programmes'
2026-07-21 entries kept), plus impact: internal on iss-113 — the
newly armed issue_impact_valid blocker fires on it post-merge, and
its fix landed on the same branch as the behavior it corrects, so
nothing is user-visible relative to the last release.
@REPPL
REPPL merged commit 2a8cd6d into main Jul 22, 2026
12 checks passed
@REPPL
REPPL deleted the feat/itd-73-p1-impact-derivation branch July 22, 2026 13:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant